"use client"; import { Fragment, useState, useEffect, useRef, useCallback, useMemo } from "react"; import Link from "next/link"; import type { EbookManifest, ChapterDraft } from "@/lib/schemas/ebook"; import type { BackMatter } from "@/lib/schemas/ebook"; import { getReadingPosition, saveReadingPosition, getReaderSettings, saveReaderSettings, getBookmarks, addBookmark, removeBookmark, } from "@/lib/reader-store"; import type { ReaderSettings, ReaderBookmark } from "@/lib/reader-store"; import { ChapterDrawer } from "./ChapterDrawer"; import type { TocItem } from "./ChapterDrawer"; import { ReaderSettingsPanel } from "./ReaderSettings"; import { ProgressBar } from "./ProgressBar"; import { AudioReader, parseChapter } from "./AudioReader"; import { AnnotationsPanel, saveAnnotation, loadAnnotations, ANNO_COLOR_MAP } from "./AnnotationsPanel"; import type { AnnotationColor, Annotation } from "./AnnotationsPanel"; import { useAudioPlayer } from "@/lib/audio-player-context"; // ── Reader theme palette ────────────────────────────────────────────────────── const THEMES = { night: { bg: "#1c1510", text: "#e8dcc8", heading: "#f5efe4", muted: "#8a7d6e", accent: "#c4933a", border: "#2e2620", scriptureBar: "#c4933a", chrome: "rgba(20,15,10,0.94)", chromeBorder: "rgba(255,255,255,0.07)", }, parchment: { bg: "#f4e9d0", text: "#2c1a0e", heading: "#160c04", muted: "#7a5c3a", accent: "#8b5e1a", border: "#d9c8a8", scriptureBar: "#8b5e1a", chrome: "rgba(239,228,204,0.96)", chromeBorder: "rgba(0,0,0,0.09)", }, paper: { bg: "#fafafa", text: "#1a1a1a", heading: "#0d0d0d", muted: "#6b6b6b", accent: "#333333", border: "#e0e0e0", scriptureBar: "#555555", chrome: "rgba(250,250,250,0.96)", chromeBorder: "rgba(0,0,0,0.09)", }, } as const; type Theme = typeof THEMES[keyof typeof THEMES]; const FONT_SIZES = [15, 16, 18, 20, 22] as const; const LINE_HEIGHTS = [1.65, 1.85, 2.05] as const; const FONT_FAMILIES = { serif: "Georgia, 'Times New Roman', Times, serif", sans: "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif", } as const; const CHROME_H = "3.25rem"; const VOICE_STUDIO_STORAGE_PREFIX = "nexus_voice_studio_"; type WordTapHandler = (wordIndex: number) => void; function renderWordSpans( text: string, renderWord: (word: string, wordIndex: number) => React.ReactNode, ): React.ReactNode[] { const tokens = text.split(/(\s+)/); let wordIndex = 0; return tokens.map((token, i) => { if (/^\s+$/.test(token) || token.length === 0) return {token}; const rendered = renderWord(token, wordIndex); wordIndex += 1; return {rendered}; }); } // ── Bionic reading — bold fixation-point prefix of each word ───────────────── // The first ~45% of each word is bolded; the rest rendered at normal weight. // This guides the eye to the fixation point and increases reading speed. function BionicText({ text, onWordTap }: { text: string; onWordTap?: WordTapHandler }) { return ( <> {renderWordSpans(text, (tok, wordIndex) => { const clean = tok.replace(/\*+/g, ""); const boldLen = Math.max(1, Math.ceil(clean.length * 0.45)); return ( { e.stopPropagation(); onWordTap(wordIndex); } : undefined} data-word-index={onWordTap ? wordIndex : undefined} style={{ cursor: onWordTap ? "pointer" : undefined, touchAction: "manipulation" }} > {clean.slice(0, boldLen)} {clean.slice(boldLen)} ); })} ); } // ── Inline markdown renderer ────────────────────────────────────────────────── // Detects a Bible reference at the start of a paragraph line, e.g.: // "Colossians 1:12–14 puts it this way:", "1 Corinthians 13:4", "Psalm 23:1–6" const BIBLE_REF_START = /^(?:\d\s+)?[A-Z][a-z]+(?:(?:\s+of\s+|\s+)[A-Z]?[a-z]+)?\s+\d+:\d+/; function InlineText({ text, onWordTap }: { text: string; onWordTap?: WordTapHandler }) { const parts: { t: "text" | "bold" | "italic" | "bolditalic" | "strike"; v: string }[] = []; const re = /(\*\*\*([^*\n]+?)\*\*\*|\*\*([^*\n]+?)\*\*|\*([^*\n]+?)\*|~~([^~\n]+?)~~)/g; let last = 0; let m: RegExpExecArray | null; while ((m = re.exec(text)) !== null) { if (m.index > last) parts.push({ t: "text", v: text.slice(last, m.index) }); if (m[0].startsWith("***")) parts.push({ t: "bolditalic", v: m[2] }); else if (m[0].startsWith("**")) parts.push({ t: "bold", v: m[3] }); else if (m[0].startsWith("~~")) parts.push({ t: "strike", v: m[5] }); else parts.push({ t: "italic", v: m[4] }); last = m.index + m[0].length; } if (last < text.length) parts.push({ t: "text", v: text.slice(last) }); return ( <> {(() => { let wordOffset = 0; return parts.map((p, i) => { const textNodes = renderWordSpans(p.v, (word, wordIndex) => ( { e.stopPropagation(); onWordTap(wordOffset + wordIndex); } : undefined} data-word-index={onWordTap ? wordOffset + wordIndex : undefined} style={{ cursor: onWordTap ? "pointer" : undefined, touchAction: "manipulation" }} > {word} )); const wordCount = p.v.split(/\s+/).filter(Boolean).length; const wrapped = p.t === "bolditalic" ? {textNodes} : p.t === "bold" ? {textNodes} : p.t === "italic" ? {textNodes} : p.t === "strike" ? {textNodes} : {textNodes}; wordOffset += wordCount; return wrapped; }); })()} ); } // ── Annotation highlight renderer ─────────────────────────────────────────── // Matches annotation selectedText (plain) inside a raw markdown line, // wraps matched spans in with the annotation colour. function HighlightedLine({ text, annotations, onWordTap, }: { text: string; annotations: { selectedText: string; color: AnnotationColor }[]; onWordTap?: WordTapHandler; }) { // Derive plain text by stripping inline markdown const plain = text .replace(/\*\*\*([^*]+)\*\*\*/g, "$1") .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/\*([^*]+)\*/g, "$1"); type Range = { start: number; end: number; color: AnnotationColor }; const ranges: Range[] = []; for (const anno of annotations) { if (!anno.selectedText || anno.selectedText.length < 3) continue; let idx = plain.indexOf(anno.selectedText); while (idx !== -1) { ranges.push({ start: idx, end: idx + anno.selectedText.length, color: anno.color }); idx = plain.indexOf(anno.selectedText, idx + anno.selectedText.length); } } if (ranges.length === 0) return ; // Sort and remove overlaps (keep first match's colour) ranges.sort((a, b) => a.start - b.start); const merged: Range[] = []; for (const r of ranges) { const last = merged[merged.length - 1]; if (last && r.start < last.end) { last.end = Math.max(last.end, r.end); } else { merged.push({ ...r }); } } const nodes: React.ReactNode[] = []; let cursor = 0; for (const range of merged) { if (range.start > cursor) { nodes.push(); } nodes.push( , ); cursor = range.end; } if (cursor < plain.length) { nodes.push(); } return <>{nodes}; } function renderBody( text: string, theme: Theme, isFirstSection: boolean, annotations: { selectedText: string; color: AnnotationColor }[] = [], onWordTap?: WordTapHandler, paraKeyPrefix?: string, audioParaKey?: string | null, audioOpen?: boolean, bionicMode?: boolean, ): React.ReactNode[] { const lines = text.split("\n"); const nodes: React.ReactNode[] = []; let i = 0; let paraCount = 0; let blockIdx = 0; const pkey = () => paraKeyPrefix ? `${paraKeyPrefix}_b${blockIdx}` : undefined; const isAct = (k: string | undefined) => !!k && k === audioParaKey; const hlBg = (k: string | undefined): React.CSSProperties => isAct(k) ? { background: `${theme.accent}1c`, borderRadius: "0.2rem", transition: "background 0.35s ease" } : { transition: "background 0.35s ease" }; while (i < lines.length) { const raw = lines[i]; const line = raw.trim(); if (!line) { i++; continue; } // Ornamental divider — no blockIdx increment (matches AudioReader processBody) if (/^---+$/.test(line)) { nodes.push(
✦ ✦ ✦
, ); i++; continue; } // H1 / H2 / H3 — breakInside/breakAfter prevent orphaned headings at column boundary if (/^### /.test(line)) { const k = pkey(); nodes.push(

, ); blockIdx++; i++; continue; } if (/^## /.test(line)) { const k = pkey(); nodes.push(

, ); blockIdx++; i++; continue; } if (/^# /.test(line)) { const k = pkey(); nodes.push(

, ); blockIdx++; i++; continue; } // Blockquote (scripture / pull quote) if (/^> /.test(line)) { const k = pkey(); const qLines: string[] = []; while (i < lines.length && /^> /.test(lines[i].trim())) { qLines.push(lines[i].trim().slice(2)); i++; } nodes.push(
{qLines.map((ql, qi) => (

))}
, ); blockIdx++; continue; } // Unordered list if (/^[*-] /.test(line)) { const k = pkey(); const items: string[] = []; const s = i; while (i < lines.length && /^[*-] /.test(lines[i].trim())) { items.push(lines[i].trim().slice(2)); i++; } nodes.push(
    {items.map((item, ii) => (
  • ))}
, ); blockIdx++; continue; } // Ordered list if (/^\d+\. /.test(line)) { const k = pkey(); const items: string[] = []; const s = i; while (i < lines.length && /^\d+\. /.test(lines[i].trim())) { items.push(lines[i].trim().replace(/^\d+\. /, "")); i++; } nodes.push(
    {items.map((item, ii) => (
  1. ))}
, ); blockIdx++; continue; } // Scripture inline block const isInlineScripture = /^(?:\*{1,3}"[^"]{1,600}"\*{0,3}|\u201c[^\u201d]{1,600}\u201d|"[^"]{1,600}")\s*\([^)]+\d+:\d+[^)]*\)\s*$/.test(line); if (isInlineScripture) { const k = pkey(); nodes.push(

, ); blockIdx++; i++; continue; } // Paragraph paraCount++; const isDropCap = isFirstSection && paraCount === 1 && line.length > 1; const k = pkey(); nodes.push(

{isDropCap ? ( <> {line.charAt(0)} {bionicMode ? : } ) : ( bionicMode ? : )}

, ); blockIdx++; i++; } return nodes; } // ── Chapter content component ───────────────────────────────────────────────── function ChapterView({ chapter, theme, fontFamily, fontSize, lineHeight, annotations, onWordTap, audioParaKey, audioOpen, bionicMode, }: { chapter: ChapterDraft; theme: Theme; fontFamily: string; fontSize: number; lineHeight: number; annotations: { selectedText: string; color: AnnotationColor }[]; onWordTap?: WordTapHandler; audioParaKey?: string | null; audioOpen?: boolean; bionicMode?: boolean; }) { const [showExtras, setShowExtras] = useState(false); const baseStyle: React.CSSProperties = { fontFamily, fontSize: `${fontSize}px`, lineHeight, color: theme.text }; const isAct = (k: string) => k === audioParaKey; const hlBg = (k: string): React.CSSProperties => isAct(k) ? { background: `${theme.accent}1c`, borderRadius: "0.2rem", transition: "background 0.35s ease" } : { transition: "background 0.35s ease" }; return (
{/* Chapter header */}

Chapter {chapter.number}

{chapter.title}

{/* Epigraph */} {chapter.epigraph && (
{chapter.epigraph.split("\n").map((line, i, arr) => (

{line}

))}
)} {/* Chapter opener — consolidated premise + question, centered italic */} {chapter.intro && (
{renderBody(chapter.intro, theme, false, annotations, onWordTap, "intro", audioParaKey, audioOpen, bionicMode)}
)} {/* Sections */} {chapter.sections.map((section, idx) => (
{/* Never render the first section heading — it sits flush under the chapter title and reads as a subtitle. Chapter context is already set by the chapter title. */} {section.heading && idx > 0 && (

{section.heading}

)}
{renderBody(section.body, theme, idx === 0, annotations, onWordTap, `s${idx}`, audioParaKey, audioOpen, bionicMode)}
))} {/* Forward question — teaser pointing to next chapter */} {chapter.forwardQuestion && (
✦ ✦ ✦

{chapter.forwardQuestion}

)} {/* Reflection & takeaways (collapsible) */} {(chapter.reflectionQuestions.length > 0 || chapter.keyTakeaways.length > 0) && (
{showExtras && (
{chapter.keyTakeaways.length > 0 && (

Key Takeaways

    {chapter.keyTakeaways.map((item, idx2) => (
  • {item}
  • ))}
)} {chapter.reflectionQuestions.length > 0 && (

Reflection Questions

    {chapter.reflectionQuestions.map((q, qi) => (
  1. {q}
  2. ))}
)}
)}
)}
); } // ── Front / back matter section view ───────────────────────────────────────── function FrontMatterView({ title, body, theme, fontFamily, fontSize, lineHeight, annotations, onWordTap, audioParaKey, audioOpen, }: { title: string; body: string; theme: Theme; fontFamily: string; fontSize: number; lineHeight: number; annotations: { selectedText: string; color: AnnotationColor }[]; onWordTap?: WordTapHandler; audioParaKey?: string | null; audioOpen?: boolean; }) { const baseStyle: React.CSSProperties = { fontFamily, fontSize: `${fontSize}px`, lineHeight, color: theme.text }; const isAct = (k: string) => k === audioParaKey; const hlBg = (k: string): React.CSSProperties => isAct(k) ? { background: `${theme.accent}1c`, borderRadius: "0.2rem", transition: "background 0.35s ease" } : { transition: "background 0.35s ease" }; return (

{title}

{renderBody(body, theme, true, annotations, onWordTap, "s0", audioParaKey, audioOpen)}
); } // ── Copyright page view ─────────────────────────────────────────────────────── function CopyrightView({ bookTitle, authorName, year, theme, fontFamily, fontSize, lineHeight, }: { bookTitle: string; authorName: string; year: string; theme: Theme; fontFamily: string; fontSize: number; lineHeight: number; }) { return (

{bookTitle}

Copyright © {year} {authorName}

All rights reserved. No part of this publication may be reproduced, distributed, or transmitted in any form or by any means — including photocopying, recording, or other electronic or mechanical methods — without the prior written permission of the author.

Scripture quotations are taken from various Bible translations as noted in the text.

Published via Nexus Director

); } // ── Back matter view ───────────────────────────────────────────────────────── function BackMatterView({ section, backMatter, theme, fontFamily, fontSize, lineHeight, }: { section: "glossary" | "guide" | "resources" | "scripture"; backMatter: BackMatter; theme: Theme; fontFamily: string; fontSize: number; lineHeight: number; }) { const t = theme; const titleStyle: React.CSSProperties = { fontSize: "1.9em", fontWeight: 700, color: t.heading, lineHeight: 1.2, letterSpacing: "-0.02em", marginBottom: "0.5em", fontFamily: "Georgia, serif", textAlign: "center", }; const dividerStyle: React.CSSProperties = { width: "2.5em", height: "2px", background: t.accent, margin: "0.75em auto 3em", borderRadius: "1px", }; const base: React.CSSProperties = { fontFamily, fontSize: `${fontSize}px`, lineHeight, color: t.text }; if (section === "glossary") { return (

Glossary

{backMatter.glossary?.map((entry, i) => (

{entry.term}

{entry.definition}

{entry.firstAppearance && (

First appears: {entry.firstAppearance}

)}
))}
); } if (section === "guide") { return (

Reading Group Guide

{backMatter.readingGroupGuide?.map((ch, i) => (

Ch {ch.chapterNumber} · {ch.chapterTitle}

    {ch.questions.map((q, qi) => (
  1. {q}
  2. ))}
))}
); } if (section === "resources") { return (

Recommended Resources

    {backMatter.recommendedResources?.map((r, i) => (
  • {r}
  • ))}
); } if (section === "scripture") { // Deduplicate: prefer named translation over "translation unspecified" for same base reference const baseRef = (r: string) => r.replace(/\s*\([^)]+\)\s*$/, "").trim().toLowerCase(); const deduped = (backMatter.scriptureIndex ?? []).reduce<(typeof backMatter.scriptureIndex)[number][]>((acc, entry) => { const key = baseRef(entry.reference); const idx = acc.findIndex((e) => baseRef(e.reference) === key); if (idx === -1) return [...acc, entry]; const isSpecific = entry.translation && entry.translation !== "translation unspecified"; const existingIsGeneric = !acc[idx].translation || acc[idx].translation === "translation unspecified"; if (isSpecific && existingIsGeneric) { const next = [...acc]; next[idx] = entry; return next; } return acc; }, []); type ScriptureEntry = { reference: string; translation: string }; // Group by chapter; skip entries with no valid chapter — store full entry const byChapter = new Map(); for (const entry of deduped) { const validChs = (entry.chapters ?? []).filter((c) => c > 0); for (const ch of validChs) { const g = byChapter.get(ch) ?? []; g.push({ reference: entry.reference, translation: entry.translation ?? "" }); byChapter.set(ch, g); } } // Sort chapter keys numerically const chapterKeys = [...byChapter.keys()].sort((a, b) => a - b); const cleanRef = (r: string) => r.replace(/\s*\([^)]+\)\s*$/, "").trim(); const realTranslation = (t: string) => t && t.toLowerCase() !== "translation unspecified" ? t : null; return (

Scripture Index

{chapterKeys.map((chKey) => { const entries = [...(byChapter.get(chKey) ?? [])].sort((a, b) => a.reference.localeCompare(b.reference) ); return (

Chapter {chKey}

{entries.map((entry, i) => { const translation = realTranslation(entry.translation); return (
{cleanRef(entry.reference)} {translation && ( {translation} )}
); })}
); })}
); } return null; } // ── Selection popup — appears when user selects text in annotation mode ──────── const ANNO_SWATCHES: { key: AnnotationColor; dot: string }[] = [ { key: "amber", dot: "#f59e0b" }, { key: "rose", dot: "#f43f5e" }, { key: "sky", dot: "#0ea5e9" }, { key: "emerald", dot: "#10b981" }, ]; function SelectionPopup({ selection, color, note, onColorChange, onNoteChange, onSave, onCancel, t, fontFamily, }: { selection: { text: string; rect: DOMRect }; color: AnnotationColor; note: string; onColorChange: (c: AnnotationColor) => void; onNoteChange: (n: string) => void; onSave: () => void; onCancel: () => void; t: Theme; fontFamily: string; }) { const POPUP_W = 320; const POPUP_H = 220; const vw = typeof window !== "undefined" ? window.innerWidth : 400; const vh = typeof window !== "undefined" ? window.innerHeight : 800; const rawTop = selection.rect.top > POPUP_H + 16 ? selection.rect.top - POPUP_H - 10 : selection.rect.bottom + 10; const rawLeft = selection.rect.left + selection.rect.width / 2 - POPUP_W / 2; const top = Math.max(8, Math.min(rawTop, vh - POPUP_H - 8)); const left = Math.max(8, Math.min(rawLeft, vw - POPUP_W - 8)); return (
e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} onTouchStart={(e) => e.stopPropagation()} onTouchEnd={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onMouseUp={(e) => e.stopPropagation()} style={{ position: "fixed", top, left, width: POPUP_W, zIndex: 62, background: t.chrome, border: `1px solid ${t.chromeBorder}`, borderRadius: "1rem", backdropFilter: "blur(28px)", WebkitBackdropFilter: "blur(28px)", padding: "1.1rem", boxShadow: "0 20px 60px rgba(0,0,0,0.55)", }} > {/* Selected text preview */}

"{selection.text.slice(0, 100)}{selection.text.length > 100 ? "…" : ""}"

{/* Color swatches */}
Color {ANNO_SWATCHES.map(({ key, dot }) => (
{/* Note textarea — 16px font prevents iOS auto-zoom */}